fix(code-block)!: errors following a full review of the component (#DS-5482) - #1988
fix(code-block)!: errors following a full review of the component (#DS-5482)#1988artembelik wants to merge 10 commits into
Conversation
`maxHeight` was published as `InputSignal<number>` over an `undefined!` default, so a code block with no `[maxHeight]` binding reported `undefined` from a non-nullable type — an assignment to a `number` held `undefined`, and any arithmetic on it produced NaN. It reports `number | undefined` now. `KbqCodeBlockHighlight.file` was a write-only required input: a setter with no getter that kicked off highlighting as a side effect. It is a required signal input driven by an effect now, so it can finally be read. The `max-height` applied while `viewAll` is off is a `computed`. It was a getter read from a `[style.max-height.px]` binding, so it only re-evaluated when something else marked the view dirty. `softWrap`, `viewAll`, `canDownload`, `activeFileIndex` and `files` are backed by signals. They stay accessor inputs with the same types and the same two-way outputs: the component writes them as well as the binding, and a `model()` cannot carry the `booleanAttribute` / `numberAttribute` transform they need. BREAKING CHANGE: `KbqCodeBlock.maxHeight` reports `number | undefined` instead of `number`, and `KbqCodeBlockHighlight.file` is a readable required signal input rather than a write-only setter. Reported by the `code-block-optional-max-height` schematic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`files.length < activeFileIndex` left the first out-of-range index in place: three files with an active index of 2, shrunk to two, kept index 2 and the template rendered `files[2].content` from undefined. The new test fails against the old comparison and passes against `<=`. The review's own narrowing fix hoisted the `maxHeight` read out of `checkOverflow`, freezing the threshold at `ngAfterViewInit` so the resize observer compared against a stale value. The read is back inside the callback. `KbqCodeBlockHighlight` started a second highlight.js import for any `file` that arrived while the first was still loading, registering the line-numbers plugin twice, and neither continuation was cancelled on teardown. Deduped behind `loadOnce()` with an `onCleanup` guard, and the promise is cleared on failure so a later file change can still retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lskramarov
left a comment
There was a problem hiding this comment.
Code review — max effort
15 findings, most severe first. Everything below was reproduced by running code in a worktree at 21a06a93 and, where the question was "is this new?", re-run against the merge base 4bb1c9927 — each comment says which.
Headline: the migration is registered at 20.3.0-0, which ng update will never select now that 20.3.0 is tagged and main has moved every sibling to 21.0.0-0. That is the semantic conflict behind the textual one in migrations.json.
Two behaviour changes look like regressions against the merge base:
set filesnow emitsactiveFileIndexChangeand callsscrollTo()from inside an input setter — a parent that shrinksfilesand moves[(activeFileIndex)]in the same tick gets the wrong file and has its own field silently overwritten (NG0100in the other binding order).- The highlight effect's dependency set is history-dependent:
startFrom/singleLineare dead until afilechange routes through the synchronous branch, then live forever.
The rest split into incomplete fixes in touched code (setupContentOverflowDetection never re-arms; set activeFileIndex has no clamp; empty files still crashes; hideTabs still latches; maxHeight can still be NaN), one altitude call (loadOnce() is scoped to the directive while initLineNumbersPlugin is global), and notes on the guide, the schematic's matching, and two of the new tests.
Verified clean, for the record: check-api passes for components/code-block after a real build, the any/unknown ratchet is unchanged, prettier/eslint/cspell pass on every changed file, the Jest suite is 55/55, effects still flush before ngAfterViewInit so the pending contract is preserved, the model()-cannot-carry-a-transform justification in the code comment is correct, and no maxHeight() or .file = call site in this repo breaks on the new types.
Generated with Claude Code
`set files` no longer emits or scrolls. Resetting the index from inside an input setter wrote `activeFileIndexChange` back into a `[(activeFileIndex)]` while the parent was still updating, so a parent that shrank `files` and moved the index in one tick got the wrong file and a silently rewritten field - `NG0100` in the other binding order. The render clamps instead: `renderedFileIndex` falls back to the first file, `activeFile` reports `undefined` for an empty list, and the template renders nothing rather than reaching through `files[activeFileIndex]`. `<kbq-code-block />` and `[files]="[]"` used to throw. `activeFileIndex` also stopped accepting NaN, which `numberAttribute` produces for an unbound `index?: number` and which walked past every range guard behind a type saying `number`. `maxHeight` gets the same treatment at its transform, so `<kbq-code-block maxHeight>` and `[maxHeight]="'200px'"` report `undefined` instead of NaN - the type promised `number | undefined` and delivered NaN. The overflow gate ran once from `ngAfterViewInit`, so a `maxHeight` bound later clipped the content with neither the "view all" button nor a way in from the keyboard, and a raised limit left the button showing. It follows `maxHeight` now, and the layout reads sit behind the memoized `calculatedMaxHeight` check that would have skipped them. In the highlight directive a failed load un-latched `hljsLoading` but not `_pending`, so `pending()` stayed true for the life of the page and everything waiting on it - `scrollTo`, the overflow gate - stalled, while the rejection escaped as an uncaught error on every attempt. The load reports its failure instead of throwing it. The effect's dependency set no longer depends on which branch ran first: `startFrom` and `singleLine` are captured where the effect can see them. And the line-numbers plugin installs its `<style>` and its document-level `copy` listener once per hljs instance rather than once per directive - neither is ever removed. The schematic matched `\bKbqCodeBlock\b` over `.ts` only, which is the one shape a real call site does not have: a read through `#block="kbqCodeBlock"` lives in markup, in a file that names the module. Templates are visited, the anchors cover the module, the element and the exportAs, and patterns are matched against source with comments and string literals blanked out. Both guides and the schematic's report claimed the five signal-backed inputs "stay accessor inputs"; four of them were plain public fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Visit the preview URL for this PR (updated for commit 9829146): https://koobiq-next--prs-1988-0dw291hj.web.app (expires Sun, 13 Sep 2026 13:56:17 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c |
`hideTabs` is derived instead of written. A single file with no filename still hides the tab bar - the rule the input has always documented - but the component no longer writes `true` into its own input to get there. The write latched the bar off for the life of the component, so naming the files later never brought it back and every file past the first stayed unreachable, and it re-emitted `hideTabsChange` on every `files` assignment even when nothing had changed. `[hideTabs]="false"` no longer shows the bar for a lone unnamed file. That case only ever worked because the consumer's template happened to list `[files]` before `[hideTabs]`: with the attributes the other way round the same code hid the tabs, so what the old test pinned was attribute order rather than a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`KbqCodeBlock` has no `@Input` decorators left. `softWrap`, `viewAll`, `canDownload`, `files`,
`activeFileIndex` and `hideTabs` are `WritableSignal`s over a `protected` backing `input()` that
carries the `booleanAttribute` / `numberAttribute` transform and aliases the public attribute name.
`model()` would have been the obvious shape, and it does not fit: `ModelOptions` in 20.3.30 is
`{ alias, debugName }` with no `transform`, and without one a valueless attribute passes the empty
string and silently turns the feature off. `<kbq-code-block softWrap>` is in `e2e.ts` under a
screenshot baseline and `<kbq-code-block canDownload />` is in the published docs in both languages,
so that is not a theoretical shape. The backing input keeps the transform; a `linkedSignal` over it
keeps the half the component writes. `private` is not available for this - the compiler rejects it
with NG1053 - so the backing inputs are `protected` and `@docs-private`.
The two deprecated aliases stop writing into their replacements: either `canLoad` or `canDownload`
turns the download button on, and `codeFiles` applies while `files` is empty. Which of each pair won
used to depend on the order they sat in the consumer's template.
The migration is renamed `code-block-signals`, because it now rewrites rather than only reports: a
read becomes a call and a plain write becomes `.set(…)`, in TypeScript and through template reference
variables on `<kbq-code-block>`. Compound assignments and increments are reported instead - they
would need the receiver spelled twice - as are the backing inputs and the two deprecated aliases,
which cannot be written at all any more. The seeded copy of the shared machinery also carries the
`!x.member` misclassification the sibling migrations have, fixed here.
`check-public-api-any` records code-block at 15: six `InputSignalWithTransform<T, unknown>` replaced
six `ngAcceptInputType_*`, and only the former are counted. The `unknown` is Angular's own parameter
for what the transform accepts; narrowing it would reject valid bindings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🚨 E2E tests failedReview the report for details. 💡 Comment |
The reseeded migration lost two reports the schematic it replaced emitted: `maxHeight` reads and `KbqCodeBlockHighlight.file` writes matched neither member set, so the only mention left was a line in the once-per-project summary with no file attached - while the guide still tells consumers to fix exactly those call sites. Both are reported per file again, and the receiver pass resolves the highlight directive as a type of its own. `WRITABLE_MEMBERS` aliased `SIGNAL_MEMBERS`, so the flag threaded through `classifyAccess` was always true; the set and the parameter are gone. `onSelectedTabChange` compared the clicked index against the rendered one, so clicking the tab that is already on screen left an out-of-range `activeFileIndex` in place - and a later `files` that grew past it would jump there with no user action. It compares against the bound index instead. The overflow gate read `pending()` in the same effect that owns the ResizeObserver subscription, so every highlight tore the subscription down and rebuilt it around two forced layout reads. The subscription follows the element, a second effect retakes the measurement when highlighting settles. Tests for the paths the refactor changed and nothing covered: a valueless boolean attribute, the deprecated `canLoad` turning the download button on, and `codeFiles` filling in while `files` is empty - the shape `docs-live-example-viewer` binds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🚨 E2E tests failedReview the report for details. 💡 Comment |
The backing inputs were `protected`, which compiles inside the library and breaks every consumer: with `strictTemplates` a `[softWrap]` binding is checked against the class member the alias points at, so `docs-examples` failed with `TS2445: Property 'softWrapInput' is protected`. `ng build components` never catches it - the component's own template does not bind its own inputs - and `ts-jest` does not type-check templates at all, so the local run was green. `ng build docs-examples` is the check that reproduces it, and `tree/toggle.ts` keeps its aliased `disabledInput` public for the same reason. `PROTECTED_MEMBERS` in the migration is renamed `PLUMBING_MEMBERS`: the members it lists are public now, and its report says what they actually are - read-only inputs that carry the attribute and its transform, and report what was bound rather than what the component holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
It introduces new public API members that publish additional unknown (triggering a baseline bump against the repo’s check-public-api-any ratchet) and has a couple of fixable documentation/PR-description inconsistencies.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR completes a “full review” of code-block, finalizing its signal-based API, fixing several runtime edge cases (notably around maxHeight, hideTabs, and empty/out-of-range file selection), and adding an ng update migration (code-block-signals) plus documentation updates.
Changes:
- Refactors
KbqCodeBlockinputs/state to signals (backing inputs +linkedSignal), fixesmaxHeightsemantics, and makes max-height styling reactive viacomputed. - Refactors
KbqCodeBlockHighlight.fileinto a readable required signal input and hardens highlight.js loading (single load, no latchedpending, one-time plugin install). - Adds a
code-block-signalsmigration schematic (+ tests) and documents the migration in both EN/RU guides.
File summaries
| File | Description |
|---|---|
| tools/public_api_guard/components/code-block.api.md | Updates public API snapshot for the new signal-based surface (new signal members and backing inputs). |
| tools/check-public-api-any/baseline.json | Updates the recorded any/unknown count for code-block. |
| packages/schematics/src/migrations/code-block-signals/schema.ts | Adds TypeScript schema interface for the new migration. |
| packages/schematics/src/migrations/code-block-signals/schema.json | Adds schematic JSON schema for options (project, fix). |
| packages/schematics/src/migrations/code-block-signals/README.md | Documents what the migration rewrites/reports and how to run it. |
| packages/schematics/src/migrations/code-block-signals/index.ts | Implements AST/template rewriting for signal reads/writes and reporting for non-mechanical cases. |
| packages/schematics/src/migrations/code-block-signals/index.spec.ts | Adds schematic tests covering TS and template rewrites, reporting, idempotency, and fix=false. |
| packages/schematics/src/migrations/code-block-signals/data.ts | Centralizes member lists and user-facing report messages for the migration. |
| packages/schematics/src/migrations.json | Registers the code-block-signals migration for version 21.0.0-0. |
| packages/schematics/src/collection.json | Exposes the new migration in the schematics collection with schema. |
| packages/components/code-block/code-block.ts | Core refactor: signal-based inputs/state, reactive max-height, derived tab hiding, safer file access, overflow gate fixes. |
| packages/components/code-block/code-block.spec.ts | Adds unit coverage for unbound/invalid maxHeight, reactive max-height behavior, highlight file() readability, overflow gate, and edge cases. |
| packages/components/code-block/code-block.html | Updates template to call signals, use derived tabsHidden(), and safely render only when an active file exists. |
| packages/components/code-block/code-block-highlight.ts | Makes file a required signal input (readable), adds effect-driven highlighting, and robust/single highlight.js load behavior. |
| docs/guides/migration.ru.md | Documents the new code-block-signals migration and behavioral changes (RU). |
| docs/guides/migration.en.md | Documents the new code-block-signals migration and behavioral changes (EN). |
Review details
Suppressed comments (5)
packages/components/code-block/code-block.ts:200
viewAllInputcurrently infers its transform input type asunknown, which becomes part of the published API and is tracked bycheck-public-api-any. Narrowing the second generic to the concrete typesbooleanAttributeis intended to accept would avoid introducing anotherunknowninto the public surface.
readonly viewAllInput = input(false, { alias: 'viewAll', transform: booleanAttribute });
packages/components/code-block/code-block.ts:240
canLoadInput(new backing input) is currently exposed asInputSignalWithTransform<boolean, unknown>in the public API. Since the repo has a ratchet against growingany/unknownin the published type surface, please narrow the transform input type parameter to the actual template typesbooleanAttributecoerces.
readonly canLoadInput = input(false, { alias: 'canLoad', transform: booleanAttribute });
packages/components/code-block/code-block.ts:247
- Same as
canLoadInput:canDownloadInputcurrently publishesunknownas the transform input type. Narrowing the second generic would keep the public surface typed without adding newunknownoccurrences.
readonly canDownloadInput = input(false, { alias: 'canDownload', transform: booleanAttribute });
packages/components/code-block/code-block.ts:297
activeFileIndexInputis a newly introduced public backing input and will be emitted asInputSignalWithTransform<number, unknown>in the generated typings. To avoid growing the package’sunknowncount (tracked bycheck-public-api-any), consider narrowing the transform input type to whatnumberAttributeaccepts from templates (string/number/null/undefined).
readonly activeFileIndexInput = input(0, {
packages/components/code-block/code-block.ts:344
hideTabsInputis another new backing input that currently leaksunknowninto the public API surface viaInputSignalWithTransform<boolean, unknown>. Please consider narrowing the transform input type parameter to keep the type surface within the repo’sany/unknownratchet expectations.
readonly hideTabsInput = input(false, { alias: 'hideTabs', transform: booleanAttribute });
- Files reviewed: 16/16 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The "What it does not do" heading had the paragraph glued onto it and the rest orphaned after a blank line. The section also lost the sentence about what is reported rather than rewritten: the edit that was supposed to add it used a plain `String.replace` whose pattern no longer matched after an earlier edit, so it silently did nothing. Heading and paragraph restored together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What
A full review of
code-block, in the same shape as the 21.0.0 component reviews.maxHeightlied about being requiredA code block with no
[maxHeight]binding reportedundefinedfrom a non-nullable type: an assignment to anumberheldundefined, andmaxHeight() > 0was a NaN comparison that never matched. It reportsnumber | undefinednow — the same pathology, and the same fix, asKbqSplitButton.disabledin the first review wave.KbqCodeBlockHighlight.filewas write-onlyA required input with no getter, whose setter kicked off highlighting as a side effect — which is exactly why the automated signal migration skipped it. It is
input.required()driven by an effect now, so it can finally be read.The max-height binding never re-evaluated on its own
calculatedMaxHeightwas a getter read from[style.max-height.px], so it only recomputed when something else marked the view dirty. It is acomputedovermaxHeightandviewAllnow.That binding turned out to have no test coverage at all — the new test caught a mistake I made in the very same change (binding the computed without calling it), which is worth knowing about the component.
What deliberately stays an accessor input
softWrap,viewAll,canDownload,files,activeFileIndexandhideTabsareWritableSignals over a@docs-privatebackinginput()that carries the transform and aliases the public attribute name. Amodel()would have been the obvious shape and does not fit:ModelOptionscarries notransform, and without one a valueless attribute passes the empty string and silently turns the feature off —<kbq-code-block softWrap>is ine2e.tsunder a screenshot baseline and<kbq-code-block canDownload />is in the published docs in both languages.Template bindings are untouched —
[softWrap],[(viewAll)]and the valueless form all keep working. What changes is programmatic access: a read becomes a call and a write becomes.set(…). The deprecatedcanLoadandcodeFilesstop writing into their replacements and fill in for them instead, so which of each pair wins no longer depends on attribute order.Migration
code-block-signalsruns fromng update @koobiq/components@21(registered for21.0.0-0). It rewrites reads to calls and plain writes to.set(…)on the six signal members, in TypeScript and through template reference variables on<kbq-code-block>. It reports what has no mechanical translation: compound assignments and increments, the backing inputs, the deprecatedcanLoad/codeFilesaliases,maxHeight— which reportsnumber | undefinedand never NaN now — andKbqCodeBlockHighlight.file, which was a write-only setter and is a readable required input.Documented in
docs/guides/migration.{en,ru}.md, section 18.Testing
code-block.spec.ts: 50 → 66 tests. New coverage for the valueless boolean attribute, the deprecatedcanLoad/codeFilesfallbacks, an out-of-range and a non-numericactiveFileIndex, an emptyfiles, amaxHeightthat arrives after init, a failedhighlight.jsload, and the tab bar coming back once the files are named.code-block-signals/index.spec.ts: 13 tests.packages/components(4996 tests) andpackages/schematics(438 tests) suites pass.check-apiis in sync.BREAKING CHANGE
🤖 Generated with Claude Code